iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Software Development

Vibe Unity - AI時代的遊戲開發工作流系列 第 10

Day 10 - 使用 Cursor 進行代碼功能開發

  • 分享至 

  • xImage
  •  

這一章, 我們先來實作基本的寵物互動功能,
摸一摸 Cursor 寫代碼的工作流程, 試探一下 Cursor 的能力

在開始之前, 我們先用 Cursor 打開這個專案
然後在設定的頁面先給 Cursor 加上一些 Unity 寫代碼的規則
image.png

https://cursor.com/zh/docs/context/rules#project-rules
Cursor 的 Project Rules 是一種可以在這個專案下持續遵守的規則
其實就是一段每次對話的時候, 都會先塞進去的預製 Prompt

我們先來設定一些寫 Unity 代碼的規則:

---
description: |
  Unity C# 腳本開發規範與最佳實踐。用於指導 Cursor 在生成、修改
  `Assets/**/*.cs` 內的 Unity 腳本時,保持一致的風格與高可讀性,避免常見
  性能/架構問題。
globs:
  - Assets/**/*.cs
  - Packages/**/*.cs
alwaysApply: true
---

## 目標
- 保持代碼乾淨簡單、容易使用、清晰、可維護、可擴展。
- 避免不必要的 GC 分配與每幀開銷。
- 使用一致的命名與生命週期方法順序。
- 盡量把可以控制的變數開放在 Inspector 中進行設定。
- Function 上方加上直覺容易理解的註解

### 命名規範
- 類名/結構名:PascalCase,如 `PlayerController`。
- 方法名:PascalCase,如 `StartMovement()`。
- 變量/欄位名:camelCase,如 `moveSpeed`。常量使用 `PascalCase` 或 `UPPER_SNAKE_CASE`。
- 序列化欄位:加 `[SerializeField] private`,公開 API 再用 `public`。
- 事件:使用名詞+動詞,`OnXxxHappened`;委託型欄位以 `Handler` 或 `Action` 結尾。

### 文件與類
- 一個 `.cs` 文件僅包含一個公共類。文件名與公共類名一致。
- 避免在同檔中放置多個 MonoBehaviour。
- 在 `Awake` 做引用快取、資料初始化;避免昂貴操作。
- 在 `Start` 依賴其他物件初始化完成後再做邏輯。
- 物理邏輯放 `FixedUpdate`;跟隨攝影機或排序放 `LateUpdate`。
- 使用 `OnEnable/OnDisable` 註冊/反註冊事件,避免漏記憶體或重複註冊。

### 序列化與 Inspector
- 盡量使用 `[SerializeField] private` 暴露到 Inspector,保持封裝性。
- 使用 `[Header]`, `[Tooltip]`, `[Range]` 改善可用性。
- 頻繁查找的組件在 `Awake` 快取,如 `rigidbody2D`, `spriteRenderer`。

### Update/協程/事件
- 減少 `Update` 中的分配與反覆查找;若非每幀需要,改用事件或計時器。
- 大量定時邏輯用協程或 `InvokeRepeating`;但注意在 `OnDisable` 停止。
- 使用 `Time.deltaTime` 處理幀率無關邏輯。

### 物理
- 物理運算放在 `FixedUpdate`,並使用 `Time.fixedDeltaTime`。
- 僅在必要時使用 `Rigidbody` 與碰撞回調;避免在 `Update` 手動移動剛體。

### 資源與記憶體
- 避免每幀產生臨時字串/Boxing;重用 `StringBuilder` 或快取陣列/列表。
- 使用 `ObjectPool` 重用頻繁生成/銷毀的物件。
- 使用 `using` 或顯式釋放非受管資源。

### 日誌與錯誤處理
- 開發期可用 `Debug.Log/Warning/Error`;生產需集中管理與可關閉。
- 避免在熱路徑頻繁 `Log`;必要時加開關。

### 依賴與測試性
- 盡量依賴抽象(介面)而非具體實作,便於替換與測試。
- 使用組合優於繼承;避免深層繼承樹。

### 註釋與文件
- 寫「為什麼」而非「怎麼做」。對複雜流程添加方法註解或 XML doc。
- 不要解釋顯而易見的代碼;保持代碼自註解化。

### 格式化
- 與現有風格一致;長參數列表分行;避免過深巢狀。
- 早返回簡化條件;適度拆分長方法(>80-100 行)。

### 常見反模式(避免)
- 在 `Update` 內 `FindObjectOfType`/`GetComponent`。
- 無節制的 `new` 或 string 拼接造成 GC。
- 公開可寫欄位導致狀態不可控。
- 在 `OnDestroy` 才解註冊事件(可能不會被調用),改用 `OnDisable`。

### 檔案/資料夾結構建議
- Editor 專用腳本放 `Editor/` 並使用 `#if UNITY_EDITOR` 條件編譯。

### 範例片段(原則示意)
- 使用 `[SerializeField] private` + 屬性只讀公開。
- 在 `Awake` 快取引用;在 `OnEnable/OnDisable` 註冊/解註冊事件。
- 物理相關移動放 `FixedUpdate`。

你可以直接複製貼上, 簡單來說就是規範 Cursor 在生成代碼的時候
盡量在 Inspector 中架設好可以控制的變量, 並且寫好註解
代碼要簡單, 乾淨, 性能效率要好, 可以方便擴展和維護

接下來我們可以再開一個規則介紹遊戲的背景資訊:

image.png

---
description: 這款遊戲的開發背景資訊
globs:
alwaysApply: true
---

名字: Polar Pet
介紹: 

- 一款 2D 的寵物互動遊戲, 可以拖拽寵物, 使用道具跟寵物進行互動
- 擁有 AI 的功能, 可以呼叫 API 進行對話互動
- 寵物和道具使用 Sprite Renderer 
- 有一個 UI 可以拖放道具到場景中進行互動

根據我的指示慢慢的把功能實現

當然, 你可以自行替換成你正在開發的遊戲背景設定。


那麼接下來,我們就來實際開發一個可以拖拽寵物的功能試試看:

image.png

Prompt:

實作可以在場景中滑鼠點擊後拖拽寵物的功能:
1. 點擊拖拽後要切換動畫 - Drag
2. 放手後寵物恢復成原本的動畫 - Walk_bear
3. 滑鼠拖動的時候, 左和右移動時要Flip反轉圖片的X值
4. 拖拽的時候, 要在下方顯示陰影的效果

Code:

using UnityEngine;

/// <summary>
/// 控制寵物在場景中被滑鼠拖拽的行為:
/// 1. 按下滑鼠並拖拽時切換動畫為 Drag。
/// 2. 放開滑鼠時恢復動畫為 Walk_bear。
/// 3. 拖拽移動時依照左右方向 Flip X。
/// 4. 拖拽時顯示陰影,放開時隱藏陰影。
/// 
/// 設計原則:
/// - 盡量使用序列化欄位以便從 Inspector 設定。
/// - 在 Awake 快取常用組件,避免每幀查找。
/// - 不依賴 Update 輪詢,僅在拖拽狀態時更新。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class PetDragController : MonoBehaviour
{
	[Header("References")]
	[Tooltip("Animator,用於切換 Drag / Walk_bear 動畫")] 
	[SerializeField] private Animator animator;
	[Tooltip("主要 Sprite 用於 FlipX,若為 SpriteRenderer 放這裡;若是整個物件旋轉,改為 Transform 對象。")]
	[SerializeField] private SpriteRenderer spriteRenderer;
	[Tooltip("陰影 GameObject(例如一個半透明橢圓),拖拽時顯示")]
	[SerializeField] private GameObject shadowObject;

	[Header("Animation Names")]
	[Tooltip("拖拽時播放的動畫狀態名稱")] 
	[SerializeField] private string dragStateName = "Drag";
	[Tooltip("放手後恢復的動畫狀態名稱")] 
	[SerializeField] private string idleStateName = "Walk_bear";

	[Header("Drag Settings")]
	[Tooltip("拖拽時的 Z 軸世界座標(確保在合適排序層級)")]
	[SerializeField] private float dragWorldZ = 0f;
	[Tooltip("將螢幕滑鼠位置轉世界座標時的相機,若留空則在 Awake 取得主相機")]
	[SerializeField] private Camera targetCamera;
	[Tooltip("是否限制拖拽移動速度(避免瞬間跳動)。0 或負值表示不限制")]
	[SerializeField] private float maxDragSpeed = 0f;
	[Tooltip("Flip 方向判定的最小位移閾值,避免 微小抖動 觸發 Flip")]
	[SerializeField] private float flipMinDelta = 0.001f;

	private bool isDragging;
	private Vector3 dragOffsetWorld;
	private Vector3 lastWorldPos;

	/// <summary>
	/// 快取相機與必要組件
	/// </summary>
	private void Awake()
	{
		if (targetCamera == null)
		{
			targetCamera = Camera.main;
		}

		if (animator == null)
		{
			animator = GetComponentInChildren<Animator>();
		}

		if (spriteRenderer == null)
		{
			spriteRenderer = GetComponentInChildren<SpriteRenderer>();
		}

		if (shadowObject != null)
		{
			shadowObject.SetActive(false);
		}
		else if (spriteRenderer != null)
		{
			var shadowGO = new GameObject("Shadow");
			shadowGO.transform.SetParent(transform, false);
			shadowGO.transform.localPosition = new Vector3(0f, -0.6f, 0f);
			shadowGO.transform.localScale = new Vector3(1.0f, 0.35f, 1f);

			var shadowSr = shadowGO.AddComponent<SpriteRenderer>();
			shadowSr.sprite = spriteRenderer.sprite;
			shadowSr.color = new Color(0f, 0f, 0f, 0.25f);
			shadowSr.sortingLayerID = spriteRenderer.sortingLayerID;
			shadowSr.sortingOrder = spriteRenderer.sortingOrder - 1;

			shadowObject = shadowGO;
			shadowObject.SetActive(false);
		}
	}

	/// <summary>
	/// 滑鼠按下時進入拖拽狀態
	/// </summary>
	private void OnMouseDown()
	{
		if (targetCamera == null) return;

		isDragging = true;
		Vector3 mouseWorld = GetMouseWorldPosition();
		dragOffsetWorld = transform.position - mouseWorld;
		lastWorldPos = transform.position;

		PlayAnimation(dragStateName);
		SetShadowActive(true);
	}

	/// <summary>
	/// 滑鼠釋放時離開拖拽狀態
	/// </summary>
	private void OnMouseUp()
	{
		isDragging = false;
		PlayAnimation(idleStateName);
		SetShadowActive(false);
	}

	/// <summary>
	/// 拖拽中更新位置與 Flip
	/// </summary>
private void Update()
{
	if (!isDragging) return;
	if (targetCamera == null) return;

	// 若拖拽中,但滑鼠在物件外放開,也要正確結束拖拽
	if (Input.GetMouseButtonUp(0))
	{
		isDragging = false;
		PlayAnimation(idleStateName);
		SetShadowActive(false);
		return;
	}

	Vector3 mouseWorld = GetMouseWorldPosition();
	Vector3 targetPos = mouseWorld + dragOffsetWorld;
	targetPos.z = dragWorldZ;

	if (maxDragSpeed > 0f)
	{
		float maxDelta = maxDragSpeed * Time.deltaTime;
		transform.position = Vector3.MoveTowards(transform.position, targetPos, maxDelta);
	}
	else
	{
		transform.position = targetPos;
	}

	Vector3 delta = transform.position - lastWorldPos;
	if (Mathf.Abs(delta.x) > flipMinDelta)
	{
		SetFlipX(delta.x < 0f);
	}
	lastWorldPos = transform.position;
}

	/// <summary>
	/// 依名稱切換 Animator 狀態
	/// </summary>
	private void PlayAnimation(string stateName)
	{
		if (animator == null || string.IsNullOrEmpty(stateName)) return;
		animator.Play(stateName);
	}

	/// <summary>
	/// 控制陰影顯示
	/// </summary>
	private void SetShadowActive(bool active)
	{
		if (shadowObject == null) return;
		if (shadowObject.activeSelf == active) return;
		shadowObject.SetActive(active);
	}

	/// <summary>
	/// 設定 SpriteRenderer 的 FlipX
	/// </summary>
	private void SetFlipX(bool flip)
	{
		if (spriteRenderer == null) return;
		spriteRenderer.flipX = flip;
	}

	/// <summary>
	/// 從滑鼠螢幕座標換算世界座標
	/// </summary>
	private Vector3 GetMouseWorldPosition()
	{
		Vector3 screenPos = Input.mousePosition;
		screenPos.z = Mathf.Abs(targetCamera.transform.position.z - transform.position.z);
		Vector3 world = targetCamera.ScreenToWorldPoint(screenPos);
		world.z = dragWorldZ;
		return world;
	}


private void OnDisable()
{
	if (isDragging)
	{
		isDragging = false;
		SetShadowActive(false);
		PlayAnimation(idleStateName);
	}
}
}

上面是我使用的Prompt和生成的代碼,
操作的時候建議可以把 Unity MCP 的功能先關掉,
它才不會一直想要去嘗試自己 Add Component 到寵物上, 花費很多時間
代碼生成好之後, 我們可以手動在場景多增加一個 Shadow 的陰影物件

image.png

然後把代碼放到北極熊身上, 並把對應的物件放進去
記得也要在北極熊身上架 Box Collider 2D 的 Component


恭喜你, 這時候可以嘗試播放一下遊戲
一切順利的話, 你就可以得到一個可以拖拽的北極熊了 !

Note:

如果 OnMouseClick / On Mouse Drag 無法使用
可以到 Project Setting/Player, 把 Active Input Handling 設定成 Both


在下一章, 我們來繼續實作道具的功能
讓我們的北極熊寵物可以吃東西和搓澡


上一篇
Day 9 - Unity 素材 和 UI 場景擺放
系列文
Vibe Unity - AI時代的遊戲開發工作流10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言